Skip to content

Batch 4C pt1: HostConfig renderer fields + levels render-units (systemd cutover drop-ins) - #193

Merged
mousebrains merged 3 commits into
mainfrom
b4c-renderers
Jun 14, 2026
Merged

Batch 4C pt1: HostConfig renderer fields + levels render-units (systemd cutover drop-ins)#193
mousebrains merged 3 commits into
mainfrom
b4c-renderers

Conversation

@mousebrains

Copy link
Copy Markdown
Owner

Batch 4C — part 1 of N: renderer foundation + the cutover plan

This kicks off the final Batch 4 slice: replacing the hand-crafted step 5 of the paired-release install runbook with renderers driven by kayak.host.HostConfig, adding the deployer's serving-path verification, and flipping the live host to SERVING_CUTOVER=yes.

Prereqs done: #190 (deployer), #191 (levels audit-gauges), #192 (#3 docroot → /var/cache/kayak/docroot, merging today).

docs/PLAN_4c_renderers.md — the roadmap

Lays out the remaining increments, the renderer mechanism (levels render-* subcommands emitting text, per the emit-config precedent), the per-consumer drop-in spec, and the VM-validation handoff. The notable finding it records: the consumer classification, verified against systemd/ on main#191's audit-gauges promotion makes it the 6th engine consumer that must be re-pointed at the release venv, but the runbook §5c (written pre-#191) lists only 5. The plan also flags the KAYAK_UNITS (quiesce) vs KAYAK_HOST_UNITS (gate-exempt) split for the repo-shell-script DB-readers (healthcheck/config-drift).

This PR — scalar HostConfig fields only

The foundation the renderers consume, all defaulting to the current WKCC shape (keep-current-then-flip, mirroring the S3 site/region slices) so nothing changes until the cutover host.yaml ships:

field default role
service_user pat unit User=, ACL commands, KAYAK_APP_USER
service_home /home/pat KAYAK_HOME, DB/var/log dirs
release_root /opt/kayak venv/dataset paths
fpm_pool_php 8.4 /etc/php/<v>/fpm pool path

docroot (existing) is reused — it flips to /var/cache/kayak/docroot at cutover. Validators: service_user must be a bare POSIX username (it's interpolated into User= and shell ACL commands — a ; rm -rf / value is rejected), paths absolute, fpm_pool_php major.minor. The vhost server_names type lands with the nginx renderer (increment 3) that consumes it, not here.

Scope / why small

Increment 1 is deliberately schema-only and behavior-neutral — it lets the renderer mechanism + increment sequence (in the plan) get a review before I build the render-* subcommands. Next: levels render-units (the 6 class-A drop-ins) with tests asserting they match the runbook spec.

Tests

ruff/format/mypy clean; full non-slow suite 1729 passed (+4 new host-config tests: the 4 defaults, the override, and the username/path/version validators).

🤖 Generated with Claude Code

@mousebrains mousebrains changed the title Batch 4C pt1: HostConfig renderer fields + cutover plan Batch 4C pt1: HostConfig renderer fields + levels render-units (systemd cutover drop-ins) Jun 14, 2026
@mousebrains

Copy link
Copy Markdown
Owner Author

Update — increment 2 pushed (1c608f8): the systemd-unit renderer.

Since you approved the levels render-* mechanism + D-CONSUMER, I folded the first renderer into this PR (the HostConfig fields + their first consumer is one coherent unit). The PR now covers:

  1. HostConfig renderer fields (service_user, service_home, release_root, fpm_pool_php) — unchanged from before.
  2. levels render-units — emits one <unit>.service.d/cutover.conf per engine consumer (the 6 units that run levels from the venv), re-pointing each at /opt/kayak/current/venv + overriding the moved paths. Pure HostConfig→text (kayak.host_render), per the emit-config precedent; systemd-analyze verify happens on the VM.

A real finding from building it: fetch-osmb and audit-gauges default their generated-data dirs (map-layer staging; the gauge-metadata cache) relative to the install root (config.py BASE_DIR), which is read-only under /opt/kayak/current. So their drop-ins must relocate those to /var/cache/kayak/*. Two new keep-current-then-flip fields carry it (map_layers_dir, gauge_metadata_cache) — defaults are the live repo-relative paths; the cutover host.yaml flips them, exactly like docroot.

Each drop-in resets ExecStart (empty ExecStart= then the new one — else a oneshot runs both) and resets ReadWritePaths (so the writable set is exactly the cutover reality), pins DATASET_DIR to the release's dataset snapshot (not the live kayak_data clone), and sets WorkingDirectory=/opt/kayak/current. Only pipeline sets OUTPUT_DIR (the #3 docroot); status passes its --output arg.

Tests: +15 (per-unit drop-in assertions + the CLI write/manifest/error paths). Full non-slow suite 1744 passed, ruff/format/mypy clean.

Next (separate PR): increment 3 = render-nginx / render-fpm (the vhost root + open_basedir substitutions + the server_names type).

mousebrains and others added 2 commits June 14, 2026 08:53
Batch 4C foundation. docs/PLAN_4c_renderers.md lays out the remaining work to
finish the paired-release cutover: render the systemd unit drop-ins / nginx
vhosts / FPM open_basedir from host.yaml (replacing the hand-crafted runbook
step 5), add the deployer's serving-path verification gate, and flip the live
host to SERVING_CUTOVER=yes. It also records the verified consumer
classification — note that #191's audit-gauges promotion makes it the 6th
engine consumer to re-point (the runbook predates that and lists 5).

This first increment adds only the scalar HostConfig fields the renderers need,
all defaulting to the current WKCC shape (keep-current-then-flip, like the S3
slices) so nothing changes until the cutover host.yaml ships:

- service_user / service_home — the account + home the units run as
- release_root — the /opt/kayak paired-release root
- fpm_pool_php — the PHP-FPM pool version in /etc/php/<v>/fpm

Validators: service_user must be a bare POSIX username (it's interpolated into
unit User= and shell ACL commands), the paths absolute, fpm_pool_php major.minor.
docroot (existing) is reused — it flips to /var/cache/kayak/docroot at cutover.
The vhost server_names type lands with the nginx renderer (increment 3) that
consumes it, not here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…its`)

Increment 2 of Batch 4C (docs/PLAN_4c_renderers.md): the systemd-unit renderer.
`levels render-units` emits one `<unit>.service.d/cutover.conf` per engine
consumer — the six units that run `levels …` from the venv — re-pointing each at
`/opt/kayak/current/venv` and overriding the paths that move off the (now
immutable) install root. Pure HostConfig→text (kayak.host_render), mirroring
`emit-config`; `systemd-analyze verify` of the merged units happens on the VM.

Each drop-in resets ExecStart (empty `ExecStart=` then the venv binary + the same
subcommand/args) and ReadWritePaths (so the writable set exactly matches the
cutover reality), pins `DATASET_DIR` to the release's dataset snapshot (not the
live kayak_data clone), and sets `WorkingDirectory=/opt/kayak/current`.

Surfaced while building it: `fetch-osmb` and `audit-gauges` default their
generated-data dirs (map-layer staging, the gauge-metadata cache) *relative to
the install root* (config.py BASE_DIR), which is read-only under a release — so
their drop-ins must relocate those to `/var/cache/kayak/*`. Two new
keep-current-then-flip HostConfig fields carry that: `map_layers_dir`,
`gauge_metadata_cache` (defaults = the live repo-relative locations; the cutover
host.yaml flips them, same as `docroot`). Only pipeline sets `OUTPUT_DIR` (the
#3 shared docroot); status passes its `--output` arg (= `status_output`).

Tests: every drop-in resets+repoints ExecStart at the venv and resets RWP, pins
DATASET_DIR, and the per-unit specifics (pipeline docroot, fetch-osmb map-layers,
audit-gauges cache + ${AUDIT_EMAIL}, status --output, DB-only units); plus the
CLI's write/manifest/malformed-config paths. Full non-slow suite 1744 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #193 (Batch 4C pt1: HostConfig renderer fields + levels render-units)

Reviewed on the live WKCC host, cross-checking the rendered drop-ins against the actual installed units, scripts, and paths. Static review (I didn't run the suite — author reports 1744 passed); I independently re-verified the consumer enumeration and the ExecStart/RWP parity against this host. Solid foundation PR — behavior-neutral and the hard part is right. One accuracy issue in the plan's "verified" classification, plus a defense-in-depth asymmetry and a forward-looking caveat.

What's right (the load-bearing parts)

  • The 6-engine-consumer set is correct and complete. I enumerated every kayak-*.service on main: exactly six run levels from the venv — pipeline, decimate, editor-retention, fetch-osmb, status, audit-gauges — matching render-units precisely. The rest are shell scripts (healthcheck/config-drift/cert-expiry/recap/heartbeat) or host-level (backups/notify/fail-test/cert-renewal). The #191 audit-gauges→6th observation checks out.

  • The regenerated ExecStart + ReadWritePaths reproduce each base unit exactly under the keep-current defaults — verified directive-by-directive against the live units:

    unit base ReadWritePaths rendered (default) base args rendered args
    pipeline public_html DB {docroot} DB pipeline pipeline
    decimate DB DB decimate decimate
    editor-retention DB DB editor-retention
    fetch-osmb kayak/var/osmb {map_layers_dir} fetch-osmb
    status /home/pat/var parent(status_output) status --output …/status.html
    audit-gauges DB Gauge-metadata-cache DB parent(cache) audit-gauges --days 16 --email ${AUDIT_EMAIL}

    The ReadWritePaths=/ExecStart= empty-reset is the correct drop-in idiom, the writable set flips coherently with its Environment= counterpart (docroot↔OUTPUT_DIR, map_layers↔MAP_LAYERS_DIR, cache↔GAUGE_METADATA_CACHE), and ${AUDIT_EMAIL}/--days 16/status_output are reproduced verbatim. That's the part most likely to silently break a sandbox at cutover, and it's correct.

  • service_user's validator genuinely blocks User=/shell-ACL injection; the CLI mirrors emit-config and a malformed host.yaml returns 1 (confirmed it propagates through main.py's sys.exit(rc)).

1. (Medium) The plan's "verified against systemd/" classification mis-describes two class-B units

§20's table is the source of truth the eventual KAYAK_UNITS/KAYAK_HOST_UNITS derivation (open decision D-CONSUMER) will consume, so its accuracy matters. Two rows are wrong against the live scripts:

  • kayak-recap — "run *.sh, NOT the venv" is false. systemd/kayak-recap.sh runs:
    "${KAYAK_HOME}/.venv/bin/python3" "${KAYAK_HOME}/kayak/scripts/recap.py" --days … --unit 'kayak-*'
    
    It does use the venv (the editable-install one). The "reads journald, not the DB" half is correct — recap.py shells to journalctl, no DB. Net: the disposition (don't re-point) is low-impact since post-cutover it just renders a journald email from the old /home/pat/.venv tree — but it contradicts §Goal ("every levels-running consumer must point at current"), and the venv mis-statement is exactly what "verified against systemd/" should have caught.
  • kayak-heartbeat — "curl heartbeat, not the DB" is wrong on both counts. systemd/kayak-heartbeat.sh emails via mail/msmtp (no curl) and stats ${KAYAK_HOME}/DB/kayak.db (file mtime — not a content read). The disposition (no quiesce) still holds (a stat doesn't lock or read rows), but the description is inaccurate.

Recommend correcting the table so D-CONSUMER's list derivation rests on accurate facts: recap = "venv-python on a checkout script; journald only (no DB) → keep on checkout, no quiesce"; heartbeat = "mail heartbeat; stat()s the DB file (no content read) → no quiesce". Functionally the dispositions look right; the rationale just needs to match reality.

2. (Low) Path fields land in the same generated unit files as service_user, but skip its injection hardening

render_cutover_dropins interpolates the path fields by plain f-string — Environment=K={path}, ReadWritePaths={' '.join(paths)}, WorkingDirectory={path}, ExecStart={venv} … — yet _abs_path only checks startswith("/"). You deliberately tightened service_user because it's "interpolated into unit User= and shell ACL commands"; the paths land in those same files via the same mechanism with no such guard. A newline in a path field injects an arbitrary systemd directive into the drop-in (e.g. a second ExecStartPre=); a space splits a ReadWritePaths= entry. host.yaml is root-owned/trusted, so this is defense-in-depth rather than a live hole — but close the asymmetry by rejecting control chars/whitespace in the path validator. Two sub-nits while there: ^…$ lets "pat\n" pass _USERNAME_RE (Python $ matches before a trailing newline — use \A…\Z/fullmatch), and \d in _PHP_VERSION_RE matches Unicode digits (use [0-9]).

3. (Low, forward-looking) Rendered drop-ins are install-unsafe pre-cutover

Every drop-in references /opt/kayak/current (DATASET_DIR, WorkingDirectory, the ExecStart venv), which only exists after the first activation. Correct by design — render-units emits text and the cutover applies it — but a stray render-units --out-dir /etc/systemd/system on the pre-cutover host would point all six consumers' DATASET_DIR at a non-existent dir and break them. Worth a guard/warning when --out-dir targets a live systemd dir, or just a runbook note. (Also: --out-dir writes cutover.conf but never sweeps a stale one if the consumer set later shrinks — minor.)

Merge mechanics

Branch is 3 commits behind main and depends on #192's /var/cache/kayak/docroot (now merged) — that's the BLOCKED/not-up-to-date state. Rebase before merge; nothing in the diff conflicts.

Verdict

Approve in spirit — schema + renderer are clean, behavior-neutral, and the consumer/RWP/ExecStart parity (the easy-to-get-wrong part) is correct. Before this feeds the actual cutover, fix the recap/heartbeat rows in the classification table (#1), since that table is what the gate-list derivation will be built from.

Three review findings:

1. (Medium) The plan's "verified" consumer classification mis-described two
   class-B units. Corrected against the live scripts: kayak-recap runs
   `${KAYAK_HOME}/.venv/bin/python3 scripts/recap.py` — the EDITABLE-install venv
   (not `levels`), journald only, no DB; kayak-heartbeat uses mail/msmtp (not
   curl) and `stat()`s the DB file mtime (no content read). Dispositions
   (recap/heartbeat not re-pointed, not quiesced) are unchanged — only the
   rationale needed to match reality, which matters because this table is what
   the D-CONSUMER list-derivation will consume. Added a note that recap isn't a
   class-A unit (no `levels`, no DB/dataset) so §Goal doesn't reach it.

2. (Low) The path fields land in the same generated unit files as service_user
   but skipped its injection hardening: paths are f-string-interpolated into
   Environment=/ReadWritePaths=/WorkingDirectory=/ExecStart=, so a newline injects
   a directive and a space splits a ReadWritePaths= entry. _abs_path now rejects
   whitespace/control chars. Also fixed the regex sub-nits: `^…$` → `\A…\Z` on all
   four validators (Python `$` matches before a trailing newline, so "pat\n" slid
   through), and `\d` → `[0-9]` in the PHP-version regex (\d matches Unicode
   digits). host.yaml is root-owned/trusted, so this is defense-in-depth.

3. (Low) render-units --out-dir now warns when {release_root}/current doesn't
   exist yet — the drop-ins reference it, so installing them pre-cutover would
   break the six consumers. Noted the (currently moot) stale-cutover.conf sweep
   limitation inline.

Tests: +5 (trailing-newline username, Unicode-digit version, whitespace path,
the pre-cutover warning present/absent). Full non-slow suite 1749 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Thanks — sharp review. All three addressed in 48b92b1, and the branch is now rebased onto merged #192 (the BLOCKED/not-up-to-date you flagged is resolved — 0 behind, clean replay since the diffs are disjoint).

1 (Medium) — the misclassified rows. You're right; I verified against the live scripts and corrected the plan:

  • kayak-recap → runs ${KAYAK_HOME}/.venv/bin/python3 scripts/recap.py (the editable-install venv, not levels), journald only, no DB. Disposition unchanged (stays on the checkout — running old code to render a journald email is harmless; no quiesce). Added an explicit note that it's not class-A: it doesn't invoke levels and touches neither the migrated DB nor the release dataset, so §Goal doesn't reach it — and the D-CONSUMER derivation keys re-pointing off "ExecStart runs …/levels", which correctly excludes it.
  • kayak-heartbeatmail/msmtp (no curl); stat()s the DB file mtime (no content read, no lock) → no quiesce.

2 (Low) — the injection asymmetry. Closed it: _abs_path now rejects whitespace/control chars (a newline injects a directive, a space splits a ReadWritePaths= entry). Both sub-nits fixed too — ^…$\A…\Z on all four validators (so "pat\n" no longer slides through service_user/cert_host/offsite_remote), and \d[0-9] in the PHP-version regex.

3 (Low) — install-unsafe pre-cutover. render-units --out-dir now warns when {release_root}/current doesn't exist (the drop-ins reference it, so applying them before the first activation breaks the six consumers). The stale-cutover.conf-sweep gap is noted inline as a (currently moot, fixed 6-unit set) follow-up.

Tests: +5 (trailing-newline username, Unicode-digit version, whitespace path, the warning present/absent). Full non-slow suite 1749 passed; ruff/format/mypy clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant